查看原文
其他

PunkWallet | Web3.0 dApp 开发(十一)

0xJyanon 李大狗LDG
2024-11-19


0x00 目标

PunkWallet是一个开源的Ethereum钱包,它是一个Burner Wallet,并且允许我们在不同的网络之间交易。可以通过WalletConnect的方式连接Web3网站。

本文将解析钱包源码(https://github.com/leeduckgo/punk-wallet, fork from https://github.com/scaffold-eth/scaffold-eth-examples/tree/punk-wallet)的实现,并且提取一些可以复用的模块/组件代码。

0x01 What Is a Burner Wallet

BurnerWallet主要用于Mint NFT或者与未审核的DAPP进行交互,可以理解为为了某个目的临时创建出来的,区分于你的主钱包的一种钱包。

Burner钱包可以是热钱包,也可以是冷钱包,您可以只保留少量的代币来支付mint或与任何智能合约交互的汽油费。

在PunkWallet上,可以非常方便地创建多个钱包,钱包之间可以非常方便地相互转账。

0x02 How To Use

一个Demo视频(英文):

https://www.youtube.com/watch?v=lYRd1k1RBAQ&feature=youtu.be

对于英文能力不佳的同学(比如我),可以看我下面总结的要点,但还是非常建议看原视频。

2.1 运行

首先,克隆仓库:

git clone https://github.com/leeduckgo/punk-wallet

然后进入仓库目录,安装所需的包,并且开启Hardhat服务:

yarn install

yarn chain

在新的终端开启你的前端服务:

yarn start

之后你就可以在http://localhost:3000访问你的页面了!

2.2 切换网络

可以点击这里切换网络,如果为localhost,则使用Hardhat的本地区块链网络

在本地网络下,可以通过左下角的faucet来领取Token。

2.3 生成钱包

点击右上角的钱包图标,在弹窗里点击“Generate"即可生成钱包,同样,你也可以在这里查看钱包的私钥、Import或删除钱包

注意,你也可以通过修改代码,让网站显示指定地址的钱包,但是由于缺少钱包私钥,你将没法进行上述操作

2.4 转账

在下方输入地址和金额进行转账交易,进行中的交易会显示在上方,你可以选择取消或加速,每次加速将多支付10%的gas费。


2.5 连接钱包

在PunkWallet上扫描WalletConnect二维码即可连接到对应网站

如果是在PC操作,可以直接复制二维码数据并粘贴到最下面的文本框进行连接。


2.6 签名&交易

连接好钱包后,在对应网站上发起签名/交易:


0x03 主要代码分析

3.1 Provider

  1. Web3Modal

    Web3Modal是一个面向所有钱包的Web3/以太坊Provider的解决方案。它是一个易于使用的库,可帮助开发人员通过简单的可定制配置在其应用程序中添加对多个Provider的支持。默认情况下,Web3Modal库支持Injected Provider(如Metamask、Brave Wallet、Dapper、Frame、Gnosis Safe、Tally、Web3浏览器等)和WalletConnect。你还可以通过配置以支持Coinbase钱包、Torus、Portis、Fortmatic等:

    https://www.npmjs.com/package/web3modal

    这里主要分析Web3Modal在PunkWallet的使用,在App.jsx中,引入了Web3Modal并进行了基本的配置:

    /*
      Web3 modal helps us "connect" external wallets:
    */

    const web3Modal = new Web3Modal({
      // network: "mainnet", // optional
      cacheProvidertrue// optional
      providerOptions: {
        walletconnect: {
          package: WalletConnectProvider, // required
          options: {
            infuraId:INFURA_ID,
            rpc: {
              10"https://mainnet.optimism.io"// xDai
              100"https://rpc.gnosischain.com"// xDai
              137"https://polygon-rpc.com",
              31337"http://localhost:8545",
              42161"https://arb1.arbitrum.io/rpc",
              80001"https://rpc-mumbai.maticvigil.com"
            },
          },
        },
      },
    });

    其中,package的值为WalletConnectProvider:

    https://www.npmjs.com/package/@walletconnect/web3-provider

    配置完毕后,通过await web3Modal.connect()即可构造Web3Provider实例:

    const loadWeb3Modal = useCallback(async () => {
      const provider = await web3Modal.connect();
      provider.on("disconnect",()=>{
      console.log("LOGOUT!");
      logoutOfWeb3Modal()
      })
      setInjectedProvider(new Web3Provider(provider));
    }, [setInjectedProvider]);

    运行效果:


  2. BurnerProvider

    BurnerProvider是一个可以生成临时密钥对的Web3Provider库:

    https://www.npmjs.com/package/burner-provider

    生成出来的秘钥会明文保存在LocalStorage,因此安全性较低,在LocalStorage没有私钥时,将会自动生成一个私钥,具体逻辑参考Github:

    https://github.com/austintgriffith/burner-provider/blob/master/index.js

    PunkWallet中,在没有Injected Provider下,自动使用BurnerProvider(默认也是自动使用BurnerProvider)

3.2 Transactor

该函数可以构造一个交易模板,通过传入配置参数发起交易,源码见:

https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/helpers/Transactor.js

该函数逻辑是,传入上述的Provider,返回一个发起交易的函数。通过从Provider里获取Signer、Network等数据来构建并发起一个交易。

注意,由于我们还需要实现一个交易管理功能(在没有Injected Provider情况下),所以在发起交易时需要将sendTransaction的结果保存,这将在下一节讲到。

这部分代码逻辑比较单一,直接参考源码即可。源码中与Notify、notification相关的语句仅用于前端提醒功能,和交易无关,可以忽略。

3.3 TransactionManager

该类用于管理PunkWallet发起的交易,交易数据会缓存在LocalStorage,可以对交易进行增删改(加速)查。

为了提高可读性,我稍微修改了一下TransactionManager的代码,源码见

https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/helpers/TransactionManager.js

  1. 基础增、查

    增:将发起的交易通过setTransactionResponse函数添加到TransactionManager里,并通过setTransactionResponses

    函数将交易列表保存至LocalStorage

    查:调用getTransactionResponse函数获取指定nonce的交易数据。该函数通过调用getTransactionResponses来读取LocalStorage中的交易列表。

    相关代码如下:

    getStorageKey() { return STORAGE_KEY; }
    getLocalStorageChangedEventName() { return LOCAL_STORAGE_CHANGED_EVENT_NAME; }

    /**
      *获取所有交易响应
    */

    getTransactionResponses() {
     const txResStr = localStorage.getItem(this.getStorageKey());
     return txResStr ? JSON.parse(txResStr) : {};
    }
    /**
      *设置交易响应状态
    */

    setTransactionResponses(txResList) {
     localStorage.setItem(this.getStorageKey(),JSON.stringify(txResList));
     // StorageEvent doesn't work in the same window
     window.dispatchEvent(newCustomEvent(this.getLocalStorageChangedEventName()));
    }
    /**
      *获取交易响应数组
    */

    getTransactionResponsesArray() {
     return Object.values(this.getTransactionResponses())
    }

    /**
      *获取单个交易响应
    */

    getTransactionResponse(nonce) {
     return this.getTransactionResponses()[nonce];
    }
    /**
      *设置单个交易响应
    */

    setTransactionResponse(txRes) {
     const txResMap = this.getTransactionResponses();
      txResMap[txRes.nonce] = txRes;
     this.setTransactionResponses(txResMap);

    调用示例:

    let signer = userProvider.getSigner();

    // I'm not sure if all the Dapps send an array or not
    let params = payload.params;
    if (Array.isArray(params)) params = params[0];

    // Ethers uses gasLimit instead of gas
    params.gasLimit = params.gas;
    delete params.gas;

    // Speed up transaction list is filtered by chainId
    params.chainId = targetNetwork.chainId

    result = await signer.sendTransaction(params);

    **const transactionManager = new TransactionManager(userProvider, signer, true);
    transactionManager.setTransactionResponse(result);**
  2. 改(加速)

    加速本质上是修改Gas费上限,首先需要读取待加速的交易数据,并修改Gas费上限(默认提高10%),然后重新用signer发起这个交易

    相关代码如下:

    /**
     * 加速交易
     */

    speedUpTransaction(nonce, rate) {
     rate ||= 10// 等同于 rate = rate || 10,旧版本JS似乎无法识别该语法

      const txParams = this.getSpeedUpTransactionParams(nonce, rate);
     this.log("txParams", txParams);

     return txParams && this.signer.sendTransaction(txParams);
    }

    /**
     * 获取加速交易参数
     */

    getSpeedUpTransactionParams(nonce, rate) {
     const txRes = this.getTransactionResponse(nonce);

     if (!txRes) return;

     let txParams = this.getTransactionParams(txRes);
     // Legacy txs
     if (txParams.gasPrice)
      txParams.gasPrice = this.getUpdatedGasPrice(txParams.gasPrice, rate);
     // EIP1559
     else {
      txParams.maxPriorityFeePerGas = this.getUpdatedGasPrice(txParams.maxPriorityFeePerGas, rate);
      // This shouldn't be necessary, but without it polygon fails way too many times with "replacement transaction underpriced"
      txParams.maxFeePerGas = this.getUpdatedGasPrice(txParams.maxFeePerGas, rate);
     }
     return txParams;
    }

    /**
     * 获取交易参数
     */

    getTransactionParams(txRes) {
     if (!txRes) return {};

     const keys = ["type""chainId""nonce""maxPriorityFeePerGas""maxFeePerGas""gasPrice""gasLimit""from""to""value""data"];
     return keys.reduce((res, key) =>
        this.addTransactionParamIfExists(res, key, txRes[key]), {});
    }
    /**
     * 添加交易传参数
     */

    addTransactionParamIfExists(res, key, value) {
      if (value === 0 || value) {
        const keys = ["maxPriorityFeePerGas""maxFeePerGas""gasPrice""gasLimit""value"];
        if (keys.includes(key))
          value = BigNumber.from(value).toHexString();

        res[key] = value;
      }
      return res
    }

    /**
     * 计算升级后的Gas费
     */

    getUpdatedGasPrice(val, rate) {
     return BigNumber.from(val).mul(rate + 100).div(100).toHexString();
    }
  3. 删(取消)

    取消本质上是用一笔空的交易覆盖待取消交易,相关代码如下:

    /**
     * 取消交易
     */

    cancelTransaction(nonce) {
      const txParams = this.getSpeedUpTransactionParams(nonce, 10);

     // 修改交易参数
     txParams.to = txParams.from;
     txParams.data = "0x";
     txParams.value = "0x";
     this.log("txParams", txParams);

     return this.signer.sendTransaction(txParams);
    }
  4. 查询确认中交易

    上述展示的"删"和"改"的操作,是基于交易还没结束的前提下进行的,因此还需要写一个获取待确认交易列表的函数。

    通过provider实时查询交易数据即可获得指定交易的当前已确认区块数(confirmations),该值为0则表示待确认,具体代码如下:

    /**
      *获取交易确认
     */

    async getConfirmations(txRes) {
     const newTxRes = await this.provider.getTransaction(txRes.hash);

     if (!newTxRes) {
      this.log("getConfirmations newTxRes is undefined", txRes);

       // I'm not sure what is this case, but it happened
       // Maybe the transaction was just confirmed when SpeedUpTx button was hit,
        // resulting in the previous response to be confirmed,
       // and the new sped up hash to be invalid
       // Also, sometimes the provider is faulty and returns null
       let nonce = await this.provider.getTransactionCount(txRes.from);

       if (txRes.nonce <= (nonce - 1)) {
       console.log("getConfirmations nonce is already used", txRes);
        // Transaction with the same nonce was already confirmed
        this.removeTransactionResponse(txRes);
        return -1;
       }
       return 0;
      }
     console.log("newTxRes", newTxRes)
     return newTxRes.confirmations;
    }

3.4 ConnectWallet以及事件监听

PunkWallet中,使用WalletConnect来连接钱包,代码如下:

let connector;
try {
  connector = new WalletConnect(sessionDetails);
}
catch(error) {
 console.error("Couldn't connect to", sessionDetails, error);
 localStorage.removeItem("walletConnectUrl");
  return;
}

其中,sessionDetails结构如下:

{
  // Required
 uri: walletConnectUrl,
 // Required
 // Change Place
 clientMeta: {
  description"Forkable web wallet for small/quick transactions.",
  url"https://punkwallet.io",
  icons: ["https://punkwallet.io/punk.png"],
  name"🧑‍🎤 PunkWallet.io",
 },
}

walletConnectUrl为连接字符串(通过二维码扫描或复制得到)

成功连接后,通过以下语句来监听session和call:

connector.on("session_request", (err, payload) => ... )

connector.on("call_request", (err, payload) => ... )

具体代码参考App.jsx的232行:

https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/App.jsx

0x04 可复用组件分析

4.1 钱包主功能

该小节主要分析以下组件:


  1. 网络切换组件

    先定义可切换的网络列表(部分代码已省略):

    export const NETWORKS = {
      ethereum: {
        name"ethereum",
        color"#ceb0fa",
        chainId1,
        price"uniswap",
        rpcUrl`https://mainnet.infura.io/v3/${INFURA_ID}`,
        blockExplorer"https://etherscan.io/",
      },
      optimism: {
        name"optimism",
        color"#f01a37",
        price"uniswap",
        chainId10,
        blockExplorer"https://optimistic.etherscan.io/",
        rpcUrl`https://mainnet.optimism.io`,
        //gasPrice: 1000000,
      },

     ......

     polygon: {
        name"polygon",
        color"#2bbdf7",
        price"uniswap:0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0",
        chainId137,
        rpcUrl"https://polygon-rpc.com",
        faucet"https://faucet.matic.network/",
        blockExplorer"https://explorer-mainnet.maticvigil.com//",
      },
      localhost: {
        name"localhost",
        color"#666666",
        price"uniswap"// use mainnet eth price for localhost
        chainId31337,
        blockExplorer"",
        rpcUrl"http://localhost:8545",
      }
    };

    将上述列表转化为Select.Option:

    const options = Object.keys(NETWORKS).map(key =>
        <Select.Option key={key} value={NETWORKS[key].name}>
          <span style={{ color: NETWORKS[key].color, fontSize: 24 }}>{NETWORKS[key].name}</span>
        </Select.Option>

      )

    然后使用Select实现选择框:

    在PunkWallet中,每次切换网络都会刷新一次页面

    <Select
      size="large"
      defaultValue={targetNetwork.name}
      style={{ textAlign: "left", width: 170, fontSize: 30 }}
      onChange={value => {
        if (targetNetwork.chainId != NETWORKS[value].chainId) {
       window.localStorage.setItem("network", value);
          setTimeout(() => window.location.reload(), 1);
        }
      }}
    >
      {options}
    </Select>
  2. 钱包余额显示

    1. 获取余额

      首先需要获取当前的钱包余额,我们可以通过provider获取余额,获取过程可以封装为一个callback:

      const [balance, setBalance] = useState();
      const pollBalance = useCallback(
        async (provider, address) => {
          if (!provider || !address) return;

          const newBalance = await provider.getBalance(address);
          if (newBalance !== balance) setBalance(newBalance);
        },
        [provider, address],
      );

      我们通过调用pollBalance即可获取address的余额,为了增强可复用性,我们可以写一个Hook来封装这个逻辑:

      export default function useBalance(provider, address) {
        const [balance, setBalance] = useState();
       const pollBalance = useCallback(
         async (provider, address) => {
           if (!provider || !address) return;
       
           const newBalance = await provider.getBalance(address);
           if (newBalance !== balance) setBalance(newBalance);
         },
         [provider, address],
       );

       // useOnBlock将pollBalance的操作封装到provider的"block"事件中
        // 每当block改变时都会触发该事件,带一个blockNumber参数,具体实现请参考源码
        useOnBlock(provider, () => {
          if (provider && address) pollBalance(provider, address);
        });
        return balance;
      }

      为了简化逻辑,这里的代码和源代码有一些出入,具体请看源码:punk-wallet/Balance.js at master · leeduckgo/punk-wallet (github.com)

    2. 获取币价

      我们除了需要获取余额数量外,还需要获取实时币价,可以通过Uniswap获取币价。

      我们在NETWORKS里定义的网络数据,里面包含了币价的获取方式。price为"uniswap"表示通过Uniswap获取币价,对于非ETH,我们在"uniswap"后加上代币的合约地址,比如polygon下对应的price为:"uniswap:0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB"

      这里需要用到Uniswap的SDK:@uniswap/sdk - npm (npmjs.com)

      那么如何计算币价呢?我们首先构造一个DAI Token对象,来获取DAI到ETH的价格:

      const chainId = mainnetProvider.network ? mainnetProvider.network.chainId : 1;

      const ETH = WETH[chainId];
      const DAI = new Token(chainId, "0x6B175474E89094C44Da98b954EedeAC495271d0F"18);

      const pair = await Fetcher.fetchPairData(DAI, ETH, mainnetProvider);
      const route = new Route([pair], ETH);

      // 获取ETH在DAI中的价格
      const priceOfETHinDAI = parseFloat(route.midPrice.toSignificant(6));

      然后解析上述的price,构造对应代币的Token对象,并计算代币在ETH中的价格,得到两者价格后即可算出最终币价:

      const contractAddress = targetNetwork.price.replace("uniswap:""");
      if (contractAddress) {
        const TOKEN = new Token(chainId, contractAddress, 18);
        const pair = await Fetcher.fetchPairData(ETH, TOKEN, mainnetProvider);
        const route = new Route([pair], TOKEN);

       // 先获取当前代币在ETH中的价格,再用此价格乘以ETH在DAI中的价格,即可算出最终币价
        const price = parseFloat(route.midPrice.toSignificant(6) * priceOfETHinDAI);

        setPrice(price); // const [price, setPrice] = useState(0);
      else {
        setPrice(priceOfETHinDAI);
      }

      当然,上述逻辑我们也可以封装成一个Hook,具体参考源码:

      https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/hooks/ExchangePrice.js

    3. 前端展示

      获取了这些数据后,前端展示就很容易实现了,我们只需要对显示格式做一些处理,并加一个代币/法币的显示切换功能即可。这里就不给出代码了,感兴趣的同学可以自行查看源码:

      https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/components/Balance.jsx

  3. 钱包二维码显示

    使用qrcode.react库绘制QRCode:

    https://www.npmjs.com/package/qrcode.react

    使用react-blockies库绘制二维码中心的随机图案:

    https://www.npmjs.com/package/react-blockies

    把这两个库合并起来即可得到钱包二维码~具体细节请看源码:

    https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/components/QRPunkBlockie.jsx

  4. 转账交易功能

    使用react-qr-reader库读取QRCode:

    https://www.npmjs.com/package/react-qr-reader

    封装一个地址输入框,实现输入地址并自动解析ENS域名并显示钱包头像。钱包头像可以复用上一节封装好的QRPunkBlockie组件。

    由于用户输入的可能是ENS域名,我们需要一个函数处理输入逻辑。在本例,我们判断输入地址是否以".eth"".xyz"结尾,如果是,解析出对应的钱包地址。对应代码如下:

    if (address.endsWith(".eth") || address.endsWith(".xyz")) {
      try {
        const possibleAddress = await ensProvider.resolveName(address);
        if (possibleAddress) address = possibleAddress;
      } catch (e) {}
    }

    金额输入的实现比较简单,这里就略过,我们完成地址输入和金额输入后,最后需要做转账按钮。该按钮将生成一定的交易参数,触发上文提到过的Transactor任务。部分代码如下:

    const gasPrice = useGasPrice(targetNetwork, "fast");
    const userProvider = useUserProvider(injectedProvider, localProvider);
    const tx = Transactor(userProvider, gasPrice, undefined, injectedProvider);

    <Button key="submit" type="primary" loading={loading}
     disabled={loading || !amount || !toAddress} 
      onClick={async () => {
        setLoading(true);

        let value;
        try { value = parseEther(amount.toString()) } 
      catch (e) {
          const floatVal = parseFloat(amount).toFixed(8);
          // failed to parseEther, try something else
          value = parseEther(floatVal.toString());
        }

        const txConfig = {
          to: toAddress, chainId: selectedChainId, value, gasPrice
        }
        const txTask = tx(txConfig);
        setAmount(""); setData("");

        const result = await txTask;
        setLoading(false);
      console.log(result);
      }}
    >
      {loading || !amount || !toAddress ? <CaretUpOutlined /> : <SendOutlined style={{ color: "#FFFFFF" }} />}{" "}
      Send
    </Button>

4.2 SpeedUpTransactions

在这里,我们会分析一下交易列表的代码


源码参考:

https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/components/SpeedUpTransactions.jsx

该组件基于上文提到的TransactionManager,因此界面的逻辑相对简单,主要负责对数据进行筛选(筛选出我们主动发起的交易),变化的监听即可。

数据筛选:

const transactionManager = new TransactionManager(provider, signer, true);

const [transactionResponsesArray, setTransactionResponsesArray] = useState([]);

const initTransactionResponsesArray = () => {
  setTransactionResponsesArray(
  injectedProvider ? [] : // 如果有Injected Provider,我们不需要管理交易
    filterResponsesAddressAndChainId(
      transactionManager.getTransactionResponsesArray()));
}
const filterResponsesAddressAndChainId = txResList => 
 // 筛选出自己发起的数据
 txResList.filter(txRes => txRes.from === address && txRes.chainId === chainId)

通过设置对localStorage的监听器,实现交易列表的实时更新显示:

useEffect(() => {
  initTransactionResponsesArray();

  // Listen for storage change events from the same and from other windows as well
 window.addEventListener("storage", initTransactionResponsesArray);
 window.addEventListener(transactionManager.getLocalStorageChangedEventName(), initTransactionResponsesArray);

  return () => {
  window.removeEventListener("storage", initTransactionResponsesArray);
  window.removeEventListener(transactionManager.getLocalStorageChangedEventName(), initTransactionResponsesArray);
  }
}, [injectedProvider, address, chainId]);

其中,transactionResponsesArray是未完成的交易数组,将该数组渲染出来,加上对应的Cancel和SpeedUp按钮,并触发TransactionManager内的对应函数即可实现我们的需求。

还有一点要注意,当一个交易完成后,需要自动清除,逻辑如下:

const updateConfirmations = async () => {
  const confirmations = await transactionManager.getConfirmations(transactionResponse);
  if (confirmations >= 1)
    transactionManager.removeTransactionResponse(transactionResponse);
}

在PunkWallet中,由TransactionResponseDisplay处理以上逻辑:

https://github.com/leeduckgo/punk-wallet/blob/master/packages/react-app/src/components/TransactionResponseDisplay.jsx

0x05 相关资料

  • https://github.com/scaffold-eth/scaffold-eth
  • https://github.com/leeduckgo/punk-wallet
  • web3modal - npm (npmjs.com)
  • burner-provider - npm (npmjs.com)
  • @uniswap/sdk - npm (npmjs.com)
  • Hardhat | Ethereum development environment for professionals by Nomic Foundation



往期回顾:

dApp 实用开发存储指南之 Gist | Web3.0 dApp 开发(十)

Staker  | Web3.0 dApp 开发(九)

Vercel 极速入门 | Web3.0 dApp 开发(八)

Token 自动售卖机 | Web3.0 dApp 开发(七)

SVG NFT 全面实践 | Web3.0 dApp 开发(六)

值的存取应用3.0 | Web3.0 dApp 开发(五)

值的存取应用2.0 |  Web3.0 dApp 开发(四)

值的存取应用1.0 | web3.0 dApp开发(三)

Scaffold-eth 快速上手 | Web3.0 dApp 开发(二)

eth.build 快速上手 | Web3.0 dApp 开发(一)

Crypto觉醒 | Web3.0 DApp 全面掌握


继续滑动看下一个
李大狗LDG
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存